Skip to content

Conversation

@pranavjana
Copy link
Contributor

credit-aggregator

Live Demo: https://credit-aggregator.vercel.app/

Overview

Live Demo: https://credit-aggregator.vercel.app/

A real-time credit card comparison tool that aggregates data from 6 major Singapore financial comparison websites using parallel Mino browser agents. Users describe their requirements in natural language, and the system dispatches concurrent AI agents to scrape and consolidate matching credit cards.

Mino API Integration

This use case demonstrates Mino API usage for browser automation.

Tech Stack

  • Next.js (TypeScript)
  • Mino API
  • AI

Contributor: Pranav Janakiraman (@pranavjana)

- ## Demo
- Live demo: https://credit-aggregator.vercel.app/
- Contributor: Pranav Janakiraman (@pranavjana)
@coderabbitai
Copy link

coderabbitai bot commented Jan 25, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a new Next.js project called credit-aggregator that enables users to compare credit cards based on specified requirements. The project includes a full-stack implementation with a React-based UI, a Node.js backend API endpoint, and integration with an external Mino API for scraping credit card data from multiple sites. The application uses Server-Sent Events (SSE) to stream real-time progress updates as the API concurrently fetches and aggregates credit card information from different sources. The project is configured with TypeScript, Tailwind CSS for styling, and includes reusable UI components for building the comparison interface.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant UI as Home Page (React)
    participant API as /api/compare-cards<br/>(Next.js API Route)
    participant Mino as Mino API<br/>(External Service)
    participant Stream as SSE Stream

    User->>UI: Enter requirements & click Search
    UI->>API: POST /api/compare-cards with requirements
    
    API->>Stream: Initialize SSE stream
    API->>API: Validate input & API key
    
    Note over API: Launch concurrent site fetches
    
    loop For each credit card site
        API->>Stream: Emit site_start event
        API->>Mino: POST request with site URL & goal
        Mino-->>API: Stream SSE events with progress
        API->>Stream: Emit site_step events
        API->>API: Parse & normalize card data
        Mino-->>API: Completion/error event
        API->>Stream: Emit site_complete event with cards
    end
    
    API->>API: Deduplicate & aggregate results
    API->>Stream: Emit complete event with final results
    
    UI->>Stream: Listen to SSE events
    Stream-->>UI: site_start, site_step, site_complete events
    UI->>UI: Update progress panel in real-time
    Stream-->>UI: complete event with all cards
    UI->>UI: Display results grid
    UI-->>User: Show comparison results
Loading
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding a new credit-aggregator project that demonstrates Mino API usage for credit card comparison.
Description check ✅ Passed The description is directly related to the changeset, providing clear context about the credit-aggregator tool, its purpose, tech stack, and live demo URL.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Fix all issues with AI agents
In `@credit-aggregator/app/api/compare-cards/route.ts`:
- Around line 200-236: When handling SSE events inside the read loop, ensure the
stream is explicitly canceled before exiting: call await reader.cancel()
immediately before breaking out of the loop when handling the COMPLETE event
(the block that sets finalResult, computes siteCards via parseMinoResult and
calls sendEvent) and also call await reader.cancel() immediately before throwing
an error in the ERROR/FAILED handling branch (the block that constructs errorMsg
and throws). This will close the underlying SSE connection and prevent resource
leaks while keeping existing logic (finalResult, parseMinoResult, sendEvent)
unchanged.

In `@credit-aggregator/app/page.tsx`:
- Around line 130-137: The "complete" event handler currently calls
setResults(event.cards || []) which will overwrite any cards already accumulated
by the site_complete handler; modify the complete branch to avoid clobbering
accumulated results — either remove the setResults call in the event.type ===
"complete" branch or change it to a safe merge (e.g. use setResults(prev =>
prev.length ? prev : (event.cards || [])) so existing cards from the
site_complete handler are preserved), and keep the existing setLoading(false)
behavior; update the event.type === "complete" block (the handler referencing
setResults and setLoading) accordingly.

In `@credit-aggregator/components/ui/button.tsx`:
- Around line 39-58: The Button component currently renders a <button> without a
default type which can trigger form submits; update the render so when asChild
is false the element gets a safe default type="button" but still respects any
explicit type passed in props (i.e., set type to props.type ?? "button" on the
Comp when asChild is false), referencing the Button function, the asChild flag,
Comp variable and the spread of props so overrides remain allowed.

In `@credit-aggregator/components/ui/stepper.tsx`:
- Around line 139-163: StepperTrigger currently returns a bare div when asChild
is true and spreads {...props} after its internal onClick, which drops the
internal step change or external handlers; update StepperTrigger to use Slot
from `@radix-ui/react-slot` when asChild is true (rendering <Slot> instead of
<div>), and ensure the onClick handlers are merged (call setActiveStep(step)
then call any user-provided props.onClick) rather than letting props override
the internal handler; also move props spreading so internal props (ref,
disabled, className, merged onClick) are applied while still forwarding
remaining props to the Slot/button to preserve both internal and external
behavior.

In `@credit-aggregator/lib/mino-client.ts`:
- Around line 71-120: The SSE reader loop is returning early on completion/error
without closing the stream, leaking the HTTP connection; modify the block that
reads from reader (the loop using reader.read()) to ensure reader.cancel() is
always called before any early return (e.g., before the returns that follow
isCompleteEvent and isErrorEvent) or, better, wrap the entire read/parse loop in
a try { ... } finally { await reader.cancel().catch(()=>{}) } so the reader is
cancelled on success, error, or exceptions; locate symbols reader, parseSSELine,
isCompleteEvent, isErrorEvent, and formatStepMessage to implement the cleanup.

In `@credit-aggregator/README.md`:
- Line 7: Replace the bare URL after the "**Live Demo:**" text with proper
Markdown link syntax; specifically, change the line that currently reads "**Live
Demo:** https://credit-aggregator.vercel.app/" to use a link like "**Live
Demo:**
[https://credit-aggregator.vercel.app/](https://credit-aggregator.vercel.app/)"
or "**Live Demo:** [Live Demo](https://credit-aggregator.vercel.app/)" so it
conforms to markdownlint (MD034).
- Around line 15-18: Remove the duplicate "Demo" section in the README by
deleting the second "## Demo" heading and its placeholder line ("*[Demo
video/screenshot to be added]*"); keep the original demo content already present
earlier (the demo image and live demo link) and ensure no other references or
anchors rely on that duplicate heading "Demo".
🧹 Nitpick comments (6)
credit-aggregator/components/ui/dot-pattern.tsx (1)

48-48: Consider removing unused id attribute.

The id="pattern-circle" attribute on the circle element is not referenced anywhere in the component. If multiple DotPattern components are rendered, this could create duplicate IDs in the DOM, though it's harmless since the ID isn't used.

🔧 Suggested fix
-          <circle id="pattern-circle" cx={cx} cy={cy} r={cr} />
+          <circle cx={cx} cy={cy} r={cr} />
credit-aggregator/lib/utils.ts (1)

19-31: Type assertion lacks validation for required type field.

The data as MinoEvent cast doesn't validate that the parsed JSON actually conforms to the MinoEvent interface. If the server sends malformed data without a type field, downstream code using isCompleteEvent or isErrorEvent may behave unexpectedly.

🔧 Suggested validation
 export function parseSSELine(line: string): MinoEvent | null {
   if (!line.startsWith("data: ")) {
     return null;
   }

   try {
     const data = JSON.parse(line.slice(6));
+    if (typeof data !== 'object' || data === null || typeof data.type !== 'string') {
+      console.error("Invalid MinoEvent: missing or invalid 'type' field");
+      return null;
+    }
     return data as MinoEvent;
   } catch (error) {
     console.error("Failed to parse SSE line:", error);
     return null;
   }
 }
credit-aggregator/README.md (1)

25-31: Section title is misleading.

The "Code Snippet" heading suggests Mino API usage examples, but this section only shows standard npm setup commands. Consider renaming to "Quick Start" or adding actual Mino API code examples to demonstrate the integration.

credit-aggregator/app/page.tsx (3)

3-3: useEffect is imported but not used for cleanup; consider adding an AbortController.

The streaming fetch (lines 60-148) has no mechanism to abort on component unmount or when the user navigates away. This could lead to memory leaks or state updates on unmounted components.

♻️ Proposed fix: Add AbortController for cleanup
 const handleSubmit = async (e: React.FormEvent) => {
   e.preventDefault();
   if (!requirements.trim()) {
     setError("Please enter your credit card requirements");
     return;
   }

+  const controller = new AbortController();
+  
   setLoading(true);
   setError("");
   setResults([]);
   setSites({});
   setSelectedSite(null);

   try {
     const response = await fetch("/api/compare-cards", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ requirements }),
+      signal: controller.signal,
     });

You could store the AbortController in a ref and call controller.abort() in a cleanup effect or when the user cancels.


266-282: Cancel is cosmetic only — the backend request continues.

This button updates local state to hide the site from the UI, but the server-side scraping continues. If intentional (soft skip), consider renaming to "Hide" or adding a tooltip. If users expect actual cancellation, you'd need to implement abort signaling to the server.


34-45: Minor: loading in deps while also being set inside the effect.

This is safe due to the allDone && loading guard, but the effect will re-run after setLoading(false). Consider removing loading from dependencies and using a ref to track it, or accept the extra no-op run as negligible.

Comment on lines +200 to +236
const decoder = new TextDecoder();
let buffer = "";
let finalResult: unknown = null;

while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";

for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const event = JSON.parse(line.slice(6));

// Handle completion first
if (event.type === "COMPLETE" && event.status === "COMPLETED") {
finalResult = event.resultJson;
// Parse and send cards with site_complete so frontend can show partial results
const siteCards = parseMinoResult(finalResult).map((card) => ({ ...card, source: site.name }));
await sendEvent({
type: "site_complete",
site: site.name,
cards: siteCards,
timestamp: Date.now(),
});
break;
}

// Handle errors
if (event.type === "ERROR" || event.status === "FAILED") {
const errorMsg = event.message || event.error || "Site may have bot protection";
throw new Error(errorMsg);
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Mino API SSE COMPLETE event stream closure documentation

💡 Result:

In Mino’s POST /v1/automation/run-sse SSE response, the COMPLETE event is the terminal “automation finished” event (success or failure) and contains the final status/result payload. After you receive type: "COMPLETE", you should treat the stream as finished and stop reading/close your SSE client. [1][2]

  • Mino documents the SSE event types and explicitly defines COMPLETE = “Automation finished (success or failure)”. [2]
  • The endpoint’s example stream shows STARTEDSTREAMING_URL (optional) → PROGRESSCOMPLETE (final). [1]
  • If you’re using the browser EventSource API, the client-side way to terminate the connection is eventSource.close() once you detect completion. [3]

Sources:
[1] https://tinyfish.mintlify.app/api-reference/automation/run-browser-automation-with-sse-streaming
[2] https://docs.mino.ai/quick-start
[3] https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events


🏁 Script executed:

# First, let's look at the full context of the file to understand stream setup
find . -type f -name "route.ts" | grep -i "compare-cards"

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 123


🏁 Script executed:

# Search for where reader is created and how the response is handled
fd "compare-cards" --type f -e ts -e tsx

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 55


🏁 Script executed:

cat -n ./credit-aggregator/app/api/compare-cards/route.ts | sed -n '180,260p'

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 3296


🏁 Script executed:

# Look for the complete function that creates the reader
cat -n ./credit-aggregator/app/api/compare-cards/route.ts | sed -n '150,200p'

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 1683


🏁 Script executed:

# Check if there's any try-finally or cleanup logic around the streaming section
rg -A 50 "TextDecoder" ./credit-aggregator/app/api/compare-cards/route.ts

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 1963


🏁 Script executed:

# Check for try-finally or cleanup logic around the entire reader section
cat -n ./credit-aggregator/app/api/compare-cards/route.ts | sed -n '140,280p'

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 5339


🏁 Script executed:

# Check the broader context to see if there's any finally block or cleanup
wc -l ./credit-aggregator/app/api/compare-cards/route.ts

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 127


🏁 Script executed:

# Get the complete function to understand error handling scope
cat -n ./credit-aggregator/app/api/compare-cards/route.ts

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 17446


Cancel the SSE reader before breaking or throwing to prevent resource leaks.

When the COMPLETE event is received (line 218) or an error occurs (line 232), the code breaks or throws without calling await reader.cancel(). This leaves the underlying SSE connection open. According to Mino's documentation, the COMPLETE event signals the end of the automation—you must close the SSE client to release the stream resource.

Add await reader.cancel(); before the break statement on line 228 and before the throw statement on line 234:

Proposed fix
             if (event.type === "COMPLETE" && event.status === "COMPLETED") {
               finalResult = event.resultJson;
               // Parse and send cards with site_complete so frontend can show partial results
               const siteCards = parseMinoResult(finalResult).map((card) => ({ ...card, source: site.name }));
               await sendEvent({
                 type: "site_complete",
                 site: site.name,
                 cards: siteCards,
                 timestamp: Date.now(),
               });
+              await reader.cancel();
               break;
             }
 
             // Handle errors
             if (event.type === "ERROR" || event.status === "FAILED") {
               const errorMsg = event.message || event.error || "Site may have bot protection";
+              await reader.cancel();
               throw new Error(errorMsg);
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const decoder = new TextDecoder();
let buffer = "";
let finalResult: unknown = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const event = JSON.parse(line.slice(6));
// Handle completion first
if (event.type === "COMPLETE" && event.status === "COMPLETED") {
finalResult = event.resultJson;
// Parse and send cards with site_complete so frontend can show partial results
const siteCards = parseMinoResult(finalResult).map((card) => ({ ...card, source: site.name }));
await sendEvent({
type: "site_complete",
site: site.name,
cards: siteCards,
timestamp: Date.now(),
});
break;
}
// Handle errors
if (event.type === "ERROR" || event.status === "FAILED") {
const errorMsg = event.message || event.error || "Site may have bot protection";
throw new Error(errorMsg);
}
const decoder = new TextDecoder();
let buffer = "";
let finalResult: unknown = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const event = JSON.parse(line.slice(6));
// Handle completion first
if (event.type === "COMPLETE" && event.status === "COMPLETED") {
finalResult = event.resultJson;
// Parse and send cards with site_complete so frontend can show partial results
const siteCards = parseMinoResult(finalResult).map((card) => ({ ...card, source: site.name }));
await sendEvent({
type: "site_complete",
site: site.name,
cards: siteCards,
timestamp: Date.now(),
});
await reader.cancel();
break;
}
// Handle errors
if (event.type === "ERROR" || event.status === "FAILED") {
const errorMsg = event.message || event.error || "Site may have bot protection";
await reader.cancel();
throw new Error(errorMsg);
}
🤖 Prompt for AI Agents
In `@credit-aggregator/app/api/compare-cards/route.ts` around lines 200 - 236,
When handling SSE events inside the read loop, ensure the stream is explicitly
canceled before exiting: call await reader.cancel() immediately before breaking
out of the loop when handling the COMPLETE event (the block that sets
finalResult, computes siteCards via parseMinoResult and calls sendEvent) and
also call await reader.cancel() immediately before throwing an error in the
ERROR/FAILED handling branch (the block that constructs errorMsg and throws).
This will close the underlying SSE connection and prevent resource leaks while
keeping existing logic (finalResult, parseMinoResult, sendEvent) unchanged.

Comment on lines +130 to +137
} else if (event.type === "step") {
// Legacy step event - ignore
} else if (event.type === "complete") {
setResults(event.cards || []);
setLoading(false);
} else if (event.type === "error") {
throw new Error(event.error);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential results overwrite: complete event replaces accumulated cards.

The site_complete handler (line 119) accumulates cards incrementally, but the complete handler here overwrites results entirely. If the server sends both event types, accumulated results will be lost. Either remove the complete handler's setResults call or ensure the server doesn't send both.

🐛 Proposed fix: Remove the overwrite or make it conditional
             } else if (event.type === "step") {
               // Legacy step event - ignore
             } else if (event.type === "complete") {
-              setResults(event.cards || []);
+              // Only set results if no cards were accumulated from site_complete events
+              if (event.cards && event.cards.length > 0) {
+                setResults((prev) => prev.length === 0 ? event.cards : prev);
+              }
               setLoading(false);
             } else if (event.type === "error") {

Alternatively, if complete is only sent without per-site cards, keep it as-is but document the expected server behavior.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else if (event.type === "step") {
// Legacy step event - ignore
} else if (event.type === "complete") {
setResults(event.cards || []);
setLoading(false);
} else if (event.type === "error") {
throw new Error(event.error);
}
} else if (event.type === "step") {
// Legacy step event - ignore
} else if (event.type === "complete") {
// Only set results if no cards were accumulated from site_complete events
if (event.cards && event.cards.length > 0) {
setResults((prev) => prev.length === 0 ? event.cards : prev);
}
setLoading(false);
} else if (event.type === "error") {
throw new Error(event.error);
}
🤖 Prompt for AI Agents
In `@credit-aggregator/app/page.tsx` around lines 130 - 137, The "complete" event
handler currently calls setResults(event.cards || []) which will overwrite any
cards already accumulated by the site_complete handler; modify the complete
branch to avoid clobbering accumulated results — either remove the setResults
call in the event.type === "complete" branch or change it to a safe merge (e.g.
use setResults(prev => prev.length ? prev : (event.cards || [])) so existing
cards from the site_complete handler are preserved), and keep the existing
setLoading(false) behavior; update the event.type === "complete" block (the
handler referencing setResults and setLoading) accordingly.

Comment on lines +39 to +58
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"

return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the button.tsx file
find . -name "button.tsx" -o -name "button.ts" | head -20

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 117


🏁 Script executed:

# Also search for the file in the credit-aggregator directory structure
fd -t f "button.tsx" credit-aggregator/

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 115


🏁 Script executed:

cat -n credit-aggregator/components/ui/button.tsx

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 2724


🏁 Script executed:

# Search for Button usage in form contexts
rg -n --type=tsx -B2 -A2 '<Button' credit-aggregator/ | head -100

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 100


🏁 Script executed:

# Search for Button usage patterns
rg -n '<Button' credit-aggregator/ --no-type-list -A2 -B2 | head -150

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 516


🏁 Script executed:

# Search for Button usage patterns in the codebase
rg '<Button' credit-aggregator/ -A2 -B2 | head -200

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 415


🏁 Script executed:

# Search for all Button component usages
rg 'Button' credit-aggregator/ -A5 | grep -E '(<Button|type=)' | head -100

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 182


🏁 Script executed:

# More targeted search for Button usages and check if type is specified
rg '<Button' credit-aggregator/ -A10 | grep -v '^--$' | head -150

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 797


Default button type can trigger unintended form submits.

The Button component renders a <button> without a default type attribute, so it will submit surrounding forms by default per HTML specification. Add a safe default type="button" when asChild is false while allowing overrides.

Proposed fix to set a safe default type
 function Button({
   className,
   variant = "default",
   size = "default",
   asChild = false,
+  type,
   ...props
 }: React.ComponentProps<"button"> &
   VariantProps<typeof buttonVariants> & {
     asChild?: boolean
   }) {
   const Comp = asChild ? Slot : "button"
 
   return (
     <Comp
       data-slot="button"
       data-variant={variant}
       data-size={size}
       className={cn(buttonVariants({ variant, size, className }))}
+      type={asChild ? undefined : type ?? "button"}
       {...props}
     />
   )
 }
🤖 Prompt for AI Agents
In `@credit-aggregator/components/ui/button.tsx` around lines 39 - 58, The Button
component currently renders a <button> without a default type which can trigger
form submits; update the render so when asChild is false the element gets a safe
default type="button" but still respects any explicit type passed in props
(i.e., set type to props.type ?? "button" on the Comp when asChild is false),
referencing the Button function, the asChild flag, Comp variable and the spread
of props so overrides remain allowed.

Comment on lines +139 to +163
const StepperTrigger = React.forwardRef<HTMLButtonElement, StepperTriggerProps>(
({ asChild = false, className, children, ...props }, ref) => {
const { setActiveStep } = useStepper();
const { step, isDisabled } = useStepItem();

if (asChild) {
return <div className={className}>{children}</div>;
}

return (
<button
ref={ref}
className={cn(
"inline-flex items-center gap-3 disabled:pointer-events-none disabled:opacity-50",
className,
)}
onClick={() => setActiveStep(step)}
disabled={isDisabled}
{...props}
>
{children}
</button>
);
},
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and read the stepper.tsx file
find . -name "stepper.tsx" -type f | head -5

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 118


🏁 Script executed:

# Get file size and read the specific lines mentioned
cat -n credit-aggregator/components/ui/stepper.tsx | sed -n '130,170p'

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 1375


🏁 Script executed:

# Search for StepperTrigger usages in the codebase
rg -n --type=tsx 'StepperTrigger' -C2

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 100


🏁 Script executed:

# Search for StepperTrigger usages without tsx filter
rg -n 'StepperTrigger' -C2

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 1483


🏁 Script executed:

# Check package.json for `@radix-ui/react-slot` dependency
cat package.json | grep -A20 '"dependencies"' | head -30

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 117


🏁 Script executed:

# Find package.json in the repo
find . -name "package.json" -type f

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 128


🏁 Script executed:

# Check the repo root structure
ls -la | head -20

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 988


🏁 Script executed:

# Search for any files that import from stepper.tsx
rg -n "from.*stepper" -i

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 55


🏁 Script executed:

# Check credit-aggregator package.json for dependencies
cat credit-aggregator/package.json | grep -E '("dependencies"|"@radix-ui")' -A30

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 656


🏁 Script executed:

# Search for StepperTrigger usage specifically in credit-aggregator
rg -n 'StepperTrigger' credit-aggregator/ -C3

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 1875


🏁 Script executed:

# Search for actual usage of Stepper component in pages or app
find credit-aggregator -type f \( -name "*.tsx" -o -name "*.ts" \) -exec grep -l "Stepper" {} \; | grep -v "stepper.tsx"

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 55


🏁 Script executed:

# Look for actual StepperTrigger usage in context
rg -n '<Stepper' credit-aggregator/ -A5 -B5

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 1659


Fix asChild and onClick handling in StepperTrigger.

When asChild is true (line 144-145), the component returns a plain <div> without any click handler, preventing step advancement. Additionally, ...props spreads after onClick (line 157), allowing a consumer-provided onClick in props to override the internal step change handler.

Use Slot from @radix-ui/react-slot (available as a dependency) to support asChild properly, and merge onClick handlers to preserve both internal and external callbacks:

Proposed fix
+import { Slot } from "@radix-ui/react-slot";

const StepperTrigger = React.forwardRef<HTMLButtonElement, StepperTriggerProps>(
-  ({ asChild = false, className, children, ...props }, ref) => {
+  ({ asChild = false, className, children, onClick, ...props }, ref) => {
     const { setActiveStep } = useStepper();
     const { step, isDisabled } = useStepItem();
 
-    if (asChild) {
-      return <div className={className}>{children}</div>;
-    }
-
+    const Comp = asChild ? Slot : "button";
+
     return (
-      <button
+      <Comp
         ref={ref}
         className={cn(
           "inline-flex items-center gap-3 disabled:pointer-events-none disabled:opacity-50",
           className,
         )}
-        onClick={() => setActiveStep(step)}
+        onClick={(event) => {
+          if (isDisabled) {
+            event.preventDefault();
+            return;
+          }
+          setActiveStep(step);
+          onClick?.(event);
+        }}
         disabled={isDisabled}
         {...props}
       >
         {children}
-      </button>
+      </Comp>
     );
   },
 );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const StepperTrigger = React.forwardRef<HTMLButtonElement, StepperTriggerProps>(
({ asChild = false, className, children, ...props }, ref) => {
const { setActiveStep } = useStepper();
const { step, isDisabled } = useStepItem();
if (asChild) {
return <div className={className}>{children}</div>;
}
return (
<button
ref={ref}
className={cn(
"inline-flex items-center gap-3 disabled:pointer-events-none disabled:opacity-50",
className,
)}
onClick={() => setActiveStep(step)}
disabled={isDisabled}
{...props}
>
{children}
</button>
);
},
);
import { Slot } from "@radix-ui/react-slot";
const StepperTrigger = React.forwardRef<HTMLButtonElement, StepperTriggerProps>(
({ asChild = false, className, children, onClick, ...props }, ref) => {
const { setActiveStep } = useStepper();
const { step, isDisabled } = useStepItem();
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
className={cn(
"inline-flex items-center gap-3 disabled:pointer-events-none disabled:opacity-50",
className,
)}
onClick={(event) => {
if (isDisabled) {
event.preventDefault();
return;
}
setActiveStep(step);
onClick?.(event);
}}
disabled={isDisabled}
{...props}
>
{children}
</Comp>
);
},
);
🤖 Prompt for AI Agents
In `@credit-aggregator/components/ui/stepper.tsx` around lines 139 - 163,
StepperTrigger currently returns a bare div when asChild is true and spreads
{...props} after its internal onClick, which drops the internal step change or
external handlers; update StepperTrigger to use Slot from `@radix-ui/react-slot`
when asChild is true (rendering <Slot> instead of <div>), and ensure the onClick
handlers are merged (call setActiveStep(step) then call any user-provided
props.onClick) rather than letting props override the internal handler; also
move props spreading so internal props (ref, disabled, className, merged
onClick) are applied while still forwarding remaining props to the Slot/button
to preserve both internal and external behavior.

Comment on lines +71 to +120
while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";

for (const line of lines) {
const event = parseSSELine(line);
if (!event) continue;

events.push(event);

// Capture streaming URL if available
if (event.streamingUrl) {
streamingUrl = event.streamingUrl;
}

// Log progress if verbose
if (verbose && event.type === "STEP") {
console.log(formatStepMessage(event));
}

// Check for completion
if (isCompleteEvent(event)) {
if (verbose) {
console.log("[SUCCESS] Automation completed");
}
return {
success: true,
result: event.resultJson,
streamingUrl,
events,
};
}

// Check for errors
if (isErrorEvent(event)) {
const errorMsg = event.message || "Automation failed";
if (verbose) {
console.error(`[ERROR] ${errorMsg}`);
}
return {
success: false,
error: errorMsg,
streamingUrl,
events,
};
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n credit-aggregator/lib/mino-client.ts | sed -n '1,150p'

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 5016


🏁 Script executed:

cat -n credit-aggregator/lib/mino-client.ts | sed -n '48,142p'

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 3179


🏁 Script executed:

# Let me verify the ReadableStreamDefaultReader API and check if there are any other early return paths
cat -n credit-aggregator/lib/mino-client.ts | sed -n '34,142p' | grep -n "return"

Repository: tinyfish-io/TinyFish-cookbook

Length of output: 180


🌐 Web query:

ReadableStreamDefaultReader cancel() method JavaScript Node.js resource cleanup

💡 Result:

In Node.js (Web Streams API), ReadableStreamDefaultReader.prototype.cancel(reason) is the consumer-side way to say “I’m done—stop producing data and clean up.”

What it does:

  • Signals loss of interest and cancels the stream, even if chunks are still queued; any unread queued data is discarded and the stream won’t be readable afterward. [1]
  • Runs underlying-source cleanup: the reason is forwarded to the stream’s underlying source cancel() algorithm (if provided), which is where you should stop I/O, clear timers, close file handles/sockets, etc. [2]
  • If you already hold the reader lock, reader.cancel() is effectively the same as canceling the associated stream. [1]

How this relates to resource cleanup in Node:

  • If your stream is backed by something expensive (network/file/timers), implement the underlying source’s cancel(reason) so it actually frees those resources. reader.cancel() is the trigger that calls into that cleanup path. [2]

Practical pattern (early-exit cleanup):

const reader = stream.getReader();
try {
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    if (shouldStop(value)) {
      await reader.cancel("no longer needed"); // triggers underlying cleanup
      break;
    }
  }
} finally {
  // Optional: if you intend to hand the stream to someone else instead of canceling it,
  // release the lock (this does NOT cancel).
  // reader.releaseLock();
}

If you don’t want to discard unread queued chunks and you want another consumer to keep reading later, use releaseLock() instead of cancel() (it releases the lock and leaves queued chunks available). [3]

Sources: MDN ReadableStreamDefaultReader.cancel() [1], WHATWG Streams spec (cancel forwards reason to underlying source cancel) [2], MDN releaseLock() [3].

[1] https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader/cancel
[2] https://streams.spec.whatwg.org/
[3] https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader/releaseLock


🌐 Web query:

Mino API run-sse COMPLETE event documentation stream close

💡 Result:

For Mino’s POST /v1/automation/run-sse, the response is a Server-Sent Events stream (text/event-stream) that emits these message types: STARTED, optional STREAMING_URL, PROGRESS, COMPLETE, plus periodic HEARTBEAT keep-alives. COMPLETE includes the final status (e.g., COMPLETED) and resultJson payload. [1]

How to close the stream: Mino does not document a separate “close” event—treat type: "COMPLETE" as the terminal message (no more meaningful events are expected after it). On the client side, close your SSE connection immediately when you receive COMPLETE (e.g., eventSource.close() or abort your streaming HTTP request). [1]

Source

  1. Mino docs: “Run browser automation with SSE streaming” (/v1/automation/run-sse). [1]

Cancel the SSE reader on completion/error to avoid dangling connections.

Early returns at lines 100 and 114 exit without closing the reader, keeping the HTTP connection open and leaking resources. Additionally, the catch block at line 131 also doesn't close the reader if an error occurs during stream reading. Since the Mino API does not auto-close the stream on COMPLETE, the client must explicitly cancel the reader.

Call await reader.cancel() before all early returns, or wrap the reader operations in a try-finally block to ensure cleanup in all cases.

🐛 Proposed fix to cancel the reader
         if (isCompleteEvent(event)) {
           if (verbose) {
             console.log("[SUCCESS] Automation completed");
           }
+          await reader.cancel();
           return {
             success: true,
             result: event.resultJson,
             streamingUrl,
             events,
           };
         }
 
         // Check for errors
         if (isErrorEvent(event)) {
           const errorMsg = event.message || "Automation failed";
           if (verbose) {
             console.error(`[ERROR] ${errorMsg}`);
           }
+          await reader.cancel();
           return {
             success: false,
             error: errorMsg,
             streamingUrl,
             events,
           };
         }

Note: Consider also adding await reader.cancel() before the return at line 125 and wrapping the reader section in try-finally to handle the catch block scenario at line 131.

🤖 Prompt for AI Agents
In `@credit-aggregator/lib/mino-client.ts` around lines 71 - 120, The SSE reader
loop is returning early on completion/error without closing the stream, leaking
the HTTP connection; modify the block that reads from reader (the loop using
reader.read()) to ensure reader.cancel() is always called before any early
return (e.g., before the returns that follow isCompleteEvent and isErrorEvent)
or, better, wrap the entire read/parse loop in a try { ... } finally { await
reader.cancel().catch(()=>{}) } so the reader is cancelled on success, error, or
exceptions; locate symbols reader, parseSSELine, isCompleteEvent, isErrorEvent,
and formatStepMessage to implement the cleanup.


![credit-aggregator Demo](./assets/e74cd381-8291-4a76-b23d-c5220cc33260.jpg)

**Live Demo:** https://credit-aggregator.vercel.app/
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use markdown link syntax for the URL.

Per markdownlint (MD034), bare URLs should be wrapped in angle brackets or use proper link syntax.

Proposed fix
-**Live Demo:** https://credit-aggregator.vercel.app/
+**Live Demo:** <https://credit-aggregator.vercel.app/>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**Live Demo:** https://credit-aggregator.vercel.app/
**Live Demo:** <https://credit-aggregator.vercel.app/>
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

7-7: Bare URL used

(MD034, no-bare-urls)

🤖 Prompt for AI Agents
In `@credit-aggregator/README.md` at line 7, Replace the bare URL after the
"**Live Demo:**" text with proper Markdown link syntax; specifically, change the
line that currently reads "**Live Demo:** https://credit-aggregator.vercel.app/"
to use a link like "**Live Demo:**
[https://credit-aggregator.vercel.app/](https://credit-aggregator.vercel.app/)"
or "**Live Demo:** [Live Demo](https://credit-aggregator.vercel.app/)" so it
conforms to markdownlint (MD034).

Comment on lines +15 to +18
## Demo

*[Demo video/screenshot to be added]*

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove duplicate "Demo" section.

This section duplicates the heading from line 3 and contains only placeholder text. Consider removing it entirely since line 5 already shows a demo image and line 7 links to the live demo.

Proposed fix
-## Demo
-
-*[Demo video/screenshot to be added]*
-
----
-
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

15-15: Multiple headings with the same content

(MD024, no-duplicate-heading)

🤖 Prompt for AI Agents
In `@credit-aggregator/README.md` around lines 15 - 18, Remove the duplicate
"Demo" section in the README by deleting the second "## Demo" heading and its
placeholder line ("*[Demo video/screenshot to be added]*"); keep the original
demo content already present earlier (the demo image and live demo link) and
ensure no other references or anchors rely on that duplicate heading "Demo".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant